Require email confirmation on signup#188
Merged
Merged
Conversation
Plans a 3-commit sequence to require email confirmation on signup: foundation (PKCE flow, callback route, OTP verification), resend support, and friendly handling of unconfirmed sign-in attempts.
- Re-sequence Commit 1 into four self-contained commits: PKCE+AuthCallback,
CheckEmailNotice vendoring, config flip + SignUpForm wiring, e2e.
- Replace Inbucket REST paths with verified Mailpit endpoints
(search?query=to:..., /message/{ID}, DELETE /messages) and embed real
response shapes captured from the running container.
- Note Supabase kept the [inbucket] config block and container name for
back-compat even though the image is now supabase/mailpit.
Supabase swapped the local email-testing binary to Mailpit while leaving the [inbucket] config block name and supabase_inbucket_* container name in place for back-compat. The runtime, REST API, and UI are 100% Mailpit, so the README port table now labels the column accordingly. Convert the CLAUDE.md pointer into a proper markdown link to the README section.
Switches the Supabase client to `flowType: 'pkce'` so confirmation emails return `?code=` instead of `#access_token=`, and adds a new `/auth/callback` route that exchanges the code for a session via a route loader. On success the loader redirects to the `redirect` search param (else `/create-team`); on failure it captures to Sentry and rethrows so the inline `errorComponent` renders the back-to-sign-in affordance. Safe to land while `enable_confirmations` remains `false` — the route exists but nothing emits a magic link yet, and PKCE is fully compatible with the existing implicit-flow happy path. Adds `getPostSignupDestination` helper, integration coverage over loader branches (default destination, redirect param, missing code, supabase error, supabase rejection), and unit coverage for the helper. Refs #164
Updates the #164 plan to match what actually landed in commit 1: the auth-callback work uses a route loader and inline `errorComponent` rather than a dedicated `AuthCallback` component with `useEffect`. - New Decisions row documents the loader+inline-errorComponent choice (idiomatic for this codebase, avoids `useEffect` + `cancelled`-flag anti-pattern, `ErrorFallback` doesn't fit). - New Decisions row records the manual Sentry-capture pattern in the loader and points at #180 for the broader observability gap. - Commit 1 section rewritten: drops the AuthCallback component bullets, describes the loader/pendingComponent/errorComponent wiring directly, and replaces unit-test cases with integration-test coverage. - Critical files updated to remove `AuthCallback/` and add the new integration test. - Removes the redundant cross-issue ordering paragraph from Context (already captured in the Decisions table). - Adds the loader-error observability gap to "Known preexisting concerns" with a pointer to #180. Refs #164
Replaces the shadcn input-otp vendoring with a custom OtpInput at components/OtpInput/, fixing the slot-retargeting bug from shadcn/ui #4046 and dropping the input-otp dependency. Updates props/styling/a11y specs, restricts the test list to behavioral coverage per web/CLAUDE.md, and spells out the consumer-side and dev-artifact cleanups (REGEXP_ONLY_DIGITS, elementFromPoint polyfill, /dev/check-email scratch route).
Hand-rolled numeric OTP at components/OtpInput/ (single-input + overlay slot row via CSS Grid; fixes the shadcn/ui #4046 slot-retargeting bug where clicking a non-final slot focuses the last cell), plus the pending-state UI at components/auth/CheckEmailNotice/ that consumes it. Lands the building blocks for #164's signup-confirmation flow ahead of wiring SignUpForm into it (commit 3).
Captures the structural drifts from commit 2's implementation as Decisions rows, rewrites commit 2 to describe the end state in HEAD, and updates commits 3/5/6 wiring instructions for the new starting point. Notable: <CheckEmailNotice> is Card-only (parent provides page chrome), no <form> wrapper, no <StatusLine>, generic failure copy, centered Verify button, error below the slots. Commit 5 now marks the CardFooter as a reintroduction; commits 3 and 6 swap in place of <Card> rather than the whole form.
Move e2e coverage to commit 6 so it lands after every flow it asserts. Add two frontend integration tests (signup-resend in commit 4, signin-unconfirmed in commit 5) to cover the router + Supabase SDK wiring that unit tests and the targeted e2e suite don't reach. Pin down commit 6's OTP-input selector and Mailpit polling approach.
Flip enable_confirmations on in both Supabase stacks, ship a custom
confirmation template that includes both magic link and OTP code, and
wire SignUpForm into the existing <CheckEmailNotice> pending UI when
signUp returns no session.
- AuthContext.signUp gains an options.redirect param and returns
{ session } so the form can branch on auto-confirm vs. pending; the
redirect threads through to emailRedirectTo so deep-links survive
the email gap on the magic-link path.
- supabase client disables detectSessionInUrl so the auth-callback
loader owns the PKCE code exchange without racing the SDK's
default URL watcher.
- requireAuth route guard falls back to supabase.auth.getSession()
before redirecting; otherwise the moment between
exchangeCodeForSession and the next AuthContext render bounces a
freshly-confirmed user back to /.
- <OtpInput> gains an onComplete callback plus an onBeforeInput
splice handler. The handler fixes the same-digit-replacement case
in a full code (React's value tracker skips onChange when the new
character matches the old). Slot sizing goes responsive so the row
fits on ~320px phones.
- config-sync.spec.ts now ignores site_url and additional_redirect_urls
since they encode the web port (5173 dev, 5273 e2e) which differs by
the +100 e2e convention.
- e2e/supabase/templates symlinks to api/supabase/templates so both
stacks read the same confirmation.html.
Adds the detectSessionInUrl flag to commit 1's PKCE description, the OtpInput onComplete + onBeforeInput design to commit 2, and the route-guards / config-sync / symlink details to commit 3. Adds decision rows for the requireAuth getSession fallback and the onComplete callback. Updates the critical-files list to cover the new touch points.
Supersede commits 1-3's PKCE + custom callback design with the documented Supabase SPA pattern (implicit flow + detectSessionInUrl). Adds commits 4-8 covering revert, routing, error UX, resend, and e2e.
Replace the PKCE + /auth/callback infrastructure with Supabase's
implicit-flow defaults so the SDK detects the URL fragment and
establishes the session on whatever page the email link lands on.
- supabase client: drop flowType/detectSessionInUrl overrides
- router: delete authCallbackRoute, its Zod schema, and orphaned imports
- AuthContext.signUp: option shape becomes { emailRedirectTo } and
passes through to Supabase verbatim; default is \${origin}/
- SignUpForm: hardcode emailRedirectTo to \${origin}/ for this commit
(commit 5 makes it dynamic); navigate to search.redirect ?? '/' on
typed-OTP verify, letting indexRoute.beforeLoad own the routing call
- delete auth-destination helper + auth-callback integration test
- relax local redirect allowlist to a wildcard host; production
allowlist is managed in the Supabase dashboard
Plan: docs/plans/164-email-confirmation-signup.md commit 4.
Signed-in users hitting / now bounce to /leagues or /create-team via indexRoute.beforeLoad — mirrors the existing pattern on /sign-in and /sign-up and eliminates the marketing-page-inside-authenticated-shell visual when users return to /. SignUpForm threads search.redirect through emailRedirectTo so a user signing up at /sign-up?redirect=/join/<token> on desktop can confirm via the email link on mobile and land directly at /join/<token> signed in. Cross-browser invite preservation falls out of this.
Mirrors the Testing Strategy section's shape: default-to-none principle, the test before writing, categories of comments that earn their place vs. ones that don't, and practical heuristics. Available as a referenceable standard for prompts like "audit comments against our commenting strategy."
When Supabase's /verify rejects an email confirmation link (expired, already-used, or invalid token), bounce the user to /sign-up and surface a friendly message above the form. Recovery action — sign up again to receive a new link — lives next to where it can be taken, instead of a banner over the marketing landing page. - web/src/lib/auth-redirect.ts: new readConfirmationLinkError() helper that reads supabase.auth.initialize()'s cached error result and maps it to 'expired' | 'generic' | null based on error.details.code. - web/src/router.tsx: indexRoute.beforeLoad redirects to /sign-up with replace:true when the helper returns a code for a signed-out user. signUpRoute.beforeLoad calls the same helper (same cached promise) and returns the code as route context. - web/src/components/auth/SignUpForm/SignUpForm.tsx: reads confirmationError from useRouteContext, maps to user-facing message via a small lookup, renders <InlineError> above the form. Matches on the typed otp_expired code (verified in gotrue source) rather than parsing URL hash strings. Route context (vs. AuthContext state) keeps the message tied to the route lifecycle and avoids a cleanup effect on SignUpForm unmount.
Resend button on CheckEmailNotice triggers supabase.auth.resend via a new lib/auth-resend free function. Discriminates rate-limit (over_email_send_rate_limit) from generic failures using isAuthApiError; rate-limit shows a friendly message, generic shows a fallback message and reports to Sentry. Layout restructure groups OTP, error, and Verify in a fit-content column so Verify naturally matches the OTP slot row's width without magic numbers.
Resend ships as a free function in lib/auth-resend.ts (not an AuthContext method), with Sentry capture on unexpected failures, a typed isAuthApiError guard for rate-limit detection, and a fit-content layout that ties the Verify button to the OTP slot row without magic numbers. Plan now records the decision trail for these departures from the original commit 7 design.
Adds magic-link, OTP, and cross-browser preservation tests via a new Mailpit HTTP fixture, and migrates two pre-existing UI-signup tests off the now-confirmation-gated path so the suite stays green.
Unit and integration coverage exists for the resend wiring and the rate-limit UX path, but neither proves `supabase.auth.resend` actually produces a second email against the real Supabase + Mailpit stack. The new E2E owns that failure mode. Raise the e2e `email_sent` rate cap above the tight dev default so the suite has headroom for signup + resend in a single run. Drop the e2e per-recipient `max_frequency` below `1s` so the test doesn't have to sleep past gotrue's throttle. gotrue treats `"0s"` as unset and falls back to a hardcoded ~60s floor, so use `"1ms"` instead. Extend the dev/e2e config-sync regex to ignore both keys.
Add commit 9 section for the resend E2E, flip verification item 4 from manual to automated, and update the cross-cutting "Critical files" annotations.
✅ Deploy Preview for f1-fantasy ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
completeSignUp now wraps navigate in try/finally so completeAuthTransition runs even when navigation rejects, preventing the post-confirmation overlay from getting stuck. CheckEmailNotice.verify splits its catch: AuthApiError keeps the "code didn't match" copy; everything else gets a generic message and is captured to Sentry, mirroring the resend path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #164.
Summary
Gates signup on email confirmation. Supabase returns
session: nulluntil the user confirms via either a magic link or a typed 6-digit OTP, and the UI handles every branch of that flow.<CheckEmailNotice>pending UI with both magic-link and typed-OTP completion paths (new<OtpInput>primitive).otp_expiredetc.) route to/sign-upwith an inline error above the form — driven by route context, no AuthContext state churn.emailRedirectTocarries the post-confirm destination, so/sign-up?redirect=/join/<token>survives cross-browser confirmation.indexRoute.beforeLoadbounces authed users from/to/leaguesor/create-teambased on team state — single home for post-auth routing.resendpath doesn't writecode_challenge, breaking resend; implicit sidesteps the asymmetry.Plan:
docs/plans/164-email-confirmation-signup.md.Out of scope
email_not_confirmedreuses<CheckEmailNotice>— future work, composes cleanly).Test plan
npm run test:allgreennpm run web:lint+npm run web:format:checkgreennpm run e2egreen (covers magic-link, OTP, cross-browser invite preservation, resend)/sign-upwith the inline error/bounces to/leaguesor/create-teamProduction deployment checklist (post-merge, manual)
Local
config.tomlflips don't propagate to the hosted Supabase project — these have to be applied in the dashboard before this ships to users. Seedocs/plans/164-email-confirmation-signup.md→ "Production deployment notes" for full context. Order matters: SMTP (step 3) must be configured before the rate-limit raise (step 5) is selectable.http://localhost:5173/**wildcards; production should list specific surfaces:https://<prod-domain>/,https://<prod-domain>/join/**, plus any other paths users land on post-confirm. (**matches any sequence including path separators;*matches non-separator only.)api/supabase/templates/confirmation.html) into Authentication → Email Templates → Confirm signup. The hosted project reads templates from the dashboard, not fromapi/supabase/config.toml.email_sentsetting, which is2/hourfor dev). Note: this field only unlocks after custom SMTP is configured above.